Skip to content

Add method to ReflectionClass to instantiate with the same semantics as PDOStatement::fetchObject() #19401

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

mattdinthehouse
Copy link

@mattdinthehouse mattdinthehouse commented Aug 7, 2025

I've been using PDOStatement::fetchObject() and mysqli_result::fetch_object() for a couple years as a very rudimentary ORM, and I've extended that "design pattern" to create class objects from arbitrary data (eg JSON or $_POST payloads) using a custom method.

That custom approach uses ReflectionClass::newInstanceWithoutConstructor() and ReflectionProperty::setValue() plus some additional validation and type massaging logic to emulate the behaviour of the PDO and MySQLi methods.

This PR adds a newInstanceFromData(array $data, array $args = []) method to ReflectionClass that behaves the same as the PDO and MySQLi methods but is actually baked into PHP properly, ie: it instantiates the class and assigns the properties to the corresponding values in $data before calling the constructor. The second parameter $args is passed to the class constructor the same as ReflectionClass::newInstanceArgs().

The primary use-case for this would be non-database ORM models and DTOs.

Other things I want to look at:

  1. The implementation of this method, ReflectionClass::newInstanceArgs(), and ReflectionClass::newInstance() are all very similar so for the sake of not having duplicate code and therefore duplicate tests I want to refactor the "call the constructor with the given args" code into a common function.
  2. PDOStatement::fetch() with PDO::FETCH_CLASS also supports PDO::FETCH_PROPS_LATE flag to call the constructor before setting properties which makes sense here too. actually no this doesn't make sense here because users would just call new MyClass and pass data to the constructor like normal.

@mattdinthehouse mattdinthehouse force-pushed the feature/reflectionclass-newinstancefromdata branch 2 times, most recently from d9749d0 to fb97b37 Compare August 7, 2025 11:44
@mattdinthehouse mattdinthehouse marked this pull request as ready for review August 7, 2025 22:34
@mattdinthehouse
Copy link
Author

mattdinthehouse commented Aug 7, 2025

Hey @DanielEScherzer happy friday :)

Not sure if this one needs an RFC - I'll take your guidance on that!

But let me know your thoughts on -

  1. The implementation of this method, ReflectionClass::newInstanceArgs(), and ReflectionClass::newInstance() are all very similar so for the sake of not having duplicate code and therefore duplicate tests I want to refactor the "call the constructor with the given args" code into a common function.

and can you confirm that https://github.com/php/doc-base is the right repo to clone for documenting this new method?

Copy link
Member

@Girgias Girgias left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the semantics exposed by PDO, and didn't know that mysqli also exposed them, as they are very dubious IMHO, as creating an object without running the constructor can lead to it being in an unexpected state.

In any case you are going to need way more tests to cover readonly, asymmetric visibility, property hooks, and internal classes that prevent instantiation via new.


const zend_class_entry *old_scope = EG(fake_scope);
EG(fake_scope) = ce;
constructor = Z_OBJ_HT_P(return_value)->get_constructor(Z_OBJ_P(return_value));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may throw for internal classes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same code used by newInstance and newInstanceArgs which I'm thinking of refactoring into a shared function to cut the duplication, so would make sense to handle instantiating internal classes there but could be considered a breaking change?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure whether that's really a BC break? It would be throwing an exception either way?

We had a discussion a while ago with @DanielEScherzer and I think @nielsdos that it would probably be better to do something to fix the semantics regarding failing get_constructor, as various other places assume that you can even just check the constructor pointer on the CE.

Probably the best solution is that if the constructor field on the CE is NULL it means that it is not instantiable, and to set that field for classes that do not have a constructor to the zend_pass_function (which is what get's "called" anyway in those cases) but the downside of this approach is that you stop getting useful errors telling you how to get such an object (e.g. curl_init() for CurlHandle objects)

Copy link
Author

@mattdinthehouse mattdinthehouse Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see what you mean - because this method sets properties before calling constructors that can have weird (and potentially vulnerable) behaviour.

I've added a check for ce->type == ZEND_INTERNAL_CLASS similar to newInstanceWithoutConstructor() to prevent using newInstanceFromData() for internal classes entirely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That discussion is at #17796

Just so that I understand the semantics correctly, is there any difference between the proposed function and something like (class is given as an argument but would be from $this)

function newInstanceFromData(string $class, array $data, array $args = []) {
	$ref = new ReflectionClass($class);
	$instance = $ref->newInstanceWithoutConstructor();

	foreach ($data as $propName => $val) {
		$propAccess = $ref->getProperty($propName);
		$propAccess->setValue($instance, $val);
	}
	
	$instance->__construct(...$args);
	
	return $instance;
}

@mattdinthehouse
Copy link
Author

mattdinthehouse commented Aug 8, 2025

Thanks @Girgias :) I'll add more test coverage based on how @DanielEScherzer feels about refactoring newInstance and newInstanceArgs because they're currently untested for those newer constructs.

I don't really like the semantics exposed by PDO, and didn't know that mysqli also exposed them, as they are very dubious IMHO, as creating an object without running the constructor can lead to it being in an unexpected state.

Yeah it's definitely unexpected to set properties before calling the constructor (do you think this needs an RFC then?) but I've found it to be powerful for ORM and DTO use-cases, for example if I've got a JSON column in my DB then with the PDO and MySQLi functions I can do something like this:

<?php

final class MyClass
{
	private string $json;
	public array $data;

	public function __construct()
	{
		$this->data = json_decode( $this->json, true );
	}
}


$result = $pdo->query("SELECT `json` FROM `my_table`");

$record = $result->fetchObject(MyClass::class);

// ... do stuff with $record->data['whatever']

For a DTO use-case I can put validation logic into my constructor like so:

<?php

final class MyPayload
{
   public int $id;
   public string $name;

   public function __construct()
   {
   	if($this->id < 1) throw new InvalidArgumentException('Invalid ID');

   	if(!trim($this->name)) throw new InvalidArgumentException('Name cannot be blank');
   }
}


$schema = new ReflectionClass('MyPayload');

$dto = $schema->newInstanceFromData($_POST);

// ... do work on DTO knowing it's got fully valid data

mattdinthehouse and others added 4 commits August 8, 2025 19:17
@mattdinthehouse mattdinthehouse force-pushed the feature/reflectionclass-newinstancefromdata branch from 9f8a428 to 87572e9 Compare August 8, 2025 09:26

const zend_class_entry *old_scope = EG(fake_scope);
EG(fake_scope) = ce;
constructor = Z_OBJ_HT_P(return_value)->get_constructor(Z_OBJ_P(return_value));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That discussion is at #17796

Just so that I understand the semantics correctly, is there any difference between the proposed function and something like (class is given as an argument but would be from $this)

function newInstanceFromData(string $class, array $data, array $args = []) {
	$ref = new ReflectionClass($class);
	$instance = $ref->newInstanceWithoutConstructor();

	foreach ($data as $propName => $val) {
		$propAccess = $ref->getProperty($propName);
		$propAccess->setValue($instance, $val);
	}
	
	$instance->__construct(...$args);
	
	return $instance;
}

argc = zend_hash_num_elements(args);
}

if (UNEXPECTED(object_init_ex(return_value, ce) != SUCCESS)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the initialization isn't successful, we should confirm that there is an exception, and then RETURN_THROWS()

@@ -370,6 +370,8 @@ public function newInstanceWithoutConstructor(): object {}
/** @tentative-return-type */
public function newInstanceArgs(array $args = []): ?object {}

public function newInstanceFromData(array $data, array $args = []): ?object {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should always return an object, no nullable type needed

constructor, Z_OBJ_P(return_value), Z_OBJCE_P(return_value), NULL, 0, NULL, args);

if (EG(exception)) {
zend_object_store_ctor_failed(Z_OBJ_P(return_value));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and also RETURN_THROWS()

if (!(constructor->common.fn_flags & ZEND_ACC_PUBLIC)) {
zend_throw_exception_ex(reflection_exception_ptr, 0, "Access to non-public constructor of class %s", ZSTR_VAL(ce->name));
zval_ptr_dtor(return_value);
RETURN_NULL();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be RETURN_THROWS()

@@ -0,0 +1,30 @@
--TEST--
ReflectionClass::newInstanceFromData - internal class
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please also add tests for

  • constructor property promotion
  • constructor property promotion with read-only properties (should trigger errors about reassigning)
  • normal read-only properties getting set via the data
  • normal read-only properties getting set via the data and in the constructor (should trigger errors)
  • hooked properties - with a set hook
  • hooked properties - virtual properties
  • using the wrong type of value in the $data values (e.g trying to assign a string to an object property)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants